Repository navigation
feat(plugin-security): organization administrators assign positions and permission sets inside the D12 organization boundary - #24732
Conversation
…D12 organization boundary The delegated-administration gate gains its organization-level branch (ADR-0090 D12 as amended 2026-10-17): a holder of organization_admin / organization_admin_no_bypass who is not tenant-level creates and removes sys_user_position / sys_user_permission_set rows of its own organization when every distributed set has no superuser wildcard bit, no adminScope and only scope 'org' capabilities, and is not one of the sets the ADR keeps outside by name. The overrides open create and delete on both assignment tables; explain and the delegable-scope report learn the organization administrator. Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude <noreply@anthropic.com>
…n, and three-posture real-stack acceptance Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude <noreply@anthropic.com>
…ent boundary; changeset Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude <noreply@anthropic.com>
…he organization boundary overrides Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude <noreply@anthropic.com>
…WHERE combinators it does not implement Claude-Session: https://claude.ai/code/session_013WG7E4zFnUTehQ9UXwyQtT Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6f5d40f4a8837caedc6a9234cbadd56ddb447be9 && git checkout 6f5d40f4a8837caedc6a9234cbadd56ddb447be9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0904d856e31fd065c71af725cf3ad4ad0363dc76 20eb5f1f6d24c743c27ab7dcceb52707db7802bd && git checkout -B drift-repro 0904d856e31fd065c71af725cf3ad4ad0363dc76 && git merge --no-ff 20eb5f1f6d24c743c27ab7dcceb52707db7802bd
node scripts/docs-audit/affected-docs.mjs --json 0904d856e31fd065c71af725cf3ad4ad0363dc76
|
席位核对 · ACCEPT ·
|
…etup holders (objectstack-ai#13280) Fixes objectstack-ai#13119 Clause-②: no ## What objectstack's shipped `organization_admin` (and `organization_admin_no_bypass`) now creates and removes rows on `sys_user_position` and `sys_user_permission_set` and never edits them (`allowCreate: true`, `allowDelete: true`, `allowEdit: false`), bounded server-side by the delegated-administration gate (its own organization, its own members, in-boundary sets only). objectstack-ai/objectstack#24732 landed it at `b27f8ded5c`, and objectui's `.objectstack-sha` (`06cab4849a`) carries it. Four places here still said that administrator is read-only on both assignment objects and cited a retired objectstack pin. They now say what ships and cite the successor pin, `organization_admin creates and removes assignment rows, never edits them — both variants` (`packages/plugins/plugin-security/src/objects/rbac-objects.test.ts`, read at `06cab4849a`): - `packages/app-shell/README.md`, "The Setup half of an item" (published in `@object-ui/app-shell`'s `files[]`, so it gets a `patch` changeset); - the comments above `canAdd` / `canRemove` in `AssignedUsersSection.tsx` and `PositionHoldersSection.tsx`; - `setupHoldersGrant-12082.test.tsx`: the header, and the `ORG_ADMIN` fixture, which is now the shipped shape (create and delete true, edit false, beside the super-user wildcard). **No behaviour change.** Add still reads `relatedNew` (the create grant) and Remove still reads `relatedRowDelete` (the delete grant); neither reads `allowEdit`. Section logic is untouched. ## Pins - (a) An organization administrator with the shipped grant is offered Add (and the picker it feeds) and Remove, in both sections. This passed against the unchanged sections, which confirms the client already does the right thing for the new grant. - (b) CONTROL: the same super-user wildcard beside explicit entries with neither create nor delete (the old read-only shape) is offered neither. The explicit entry still governs. The existing `read-only` / `create-only` / `delete-only` / `full` matrix and the no-provider control are unchanged. ## Verification (at `ae98638bcf`) - `pnpm exec vitest run --maxWorkers=1 packages/app-shell/src/views/metadata-admin/setupHoldersGrant-12082.test.tsx` from the repo root, under the shared lock: `Test Files 1 passed (1)`, `Tests 20 passed (20)`. - Closure build `turbo run build --filter="@object-ui/app-shell^..." --concurrency=2` (28/28 tasks) then `@object-ui/app-shell` `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `--listFilesOnly` on `tsconfig.test.json` lists all three edited `.tsx` files. - `eslint --format json` on the three edited `.tsx` files: 3 files, 0 errors. Type-aware linting is off in `eslint.config.js` (no `parserOptions.project`), so this diff cannot change any verdict on an untouched file. - The pre-tsc battery (`check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`, `control-bytes`), `check-hand-rolled-comment-mask.mjs` and `check-changeset-fixed.mjs`: all exit 0. - Declared to CI: the full `packages/app-shell/` test directory. Locally only the edited test file ran. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ Co-authored-by: Claude <noreply@anthropic.com>
…capabilities; isolated tenant-admin assignment re-verified (objectstack-ai#7611) (objectstack-ai#13361) <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuP7eYfRU6CWMbK5okSTnHUm)_ Fixes objectstack-ai#7611 This closes the remainder that release record 6357827416 left on objectstack-ai#7611. All three items are done: one is a measurement with no source change, one is a cleanup with no behaviour change, and one is a fix for a lint warning that reproduced. Clause-②: no Metadata-protocol: none ## 1. Under `isolated`, a tenant admin still assigns through the Setup picker (measured, no change needed) **Rig.** objectstack framework built at `main` `830a63a6`, a descendant of `448374d3f8` (objectstack-ai/objectstack#24136, PR objectstack-ai/objectstack#24732). The app-showcase app plus `@objectstack/organizations` was booted twice with `os dev --seed-admin --fresh`: - `OS_TENANCY_POSTURE=isolated` with `OS_AUTH_MEMBERSHIP_POLICY=invite-only` (port 4011). The boot banner reads `Tenancy: isolated`. - `single` (port 4010). The boot banner reads `Tenancy: single`. This branch's console ran against each server. Personas were minted through `/api/v1/auth/admin/create-user`, then change-password, `organization/create`, `invite-member` and `accept-invitation`. Tara Tenant owns Acme Tenant: her `/me/permissions` lists `organization_admin` and `isPlatformAdmin: false`. Mia Member and Noah Newhire are members. Playwright drove the real pages at `/apps/setup/metadata/{position|permission}/<name>?scope=environment`, clicked **Add holder** or **Add user**, and picked the user in the `RecordPickerDialog`. Every `/api` response was recorded. **`isolated`, tenant admin Tara:** | Step on the Setup page | Request | Answer | |:--|:--|:--| | Position `contributor`, add holder Mia | `POST /api/v1/data/sys_user_position` `{"user_id":"fnBt…","position":"contributor"}` | **201**. The row carries `organization_id` = Acme Tenant, and Mia appears in the holders list. | | Position `field_ops_delegate`, add holder Mia | `POST /api/v1/data/sys_user_position` | **403** `organization administrator 'insert' on 'sys_user_position' rejected — position 'field_ops_delegate' carries permission set 'showcase_field_ops_delegate', which fails condition (b) of the organization boundary: it carries an adminScope…`. The page shows the server's sentence. | | Permission set `showcase_contributor`, add user Noah | `POST /api/v1/data/sys_user_permission_set` `{"user_id":"AE0q…","permission_set":"showcase_contributor"}` | **201** | | Permission set `admin_full_access`, add user Noah | `POST /api/v1/data/sys_user_permission_set` | **403** `permission set 'admin_full_access', which is outside the organization boundary by name…` | | Remove Noah from `showcase_contributor` | `DELETE /api/v1/data/sys_user_permission_set/Hl81…` | **200** | | Remove Mia from `contributor` | `DELETE /api/v1/data/sys_user_position/Qzul…` | **200** | | Position and permission lists | none | Read-only banner, no **New** button. | Across 393 recorded responses, none went to the retired `sys_position`, `sys_permission_set`, `sys_position_permission_set` or `sys_capability` objects. **`single`, unchanged:** | Persona | Step | Answer | |:--|:--|:--| | Platform admin | Add holder to position `contributor`, add user to set `showcase_contributor` | **201**, **201**. **New** is offered, matching objectstack-ai#12998. | | Org admin Olga (`organization_admin_no_bypass`) | Add holder to position `manager`, add user to set `showcase_executive` | **201**, **201** | | Org admin Olga | Add user to set `admin_full_access` | **403**, outside the organization boundary by name | | Org admin Olga | Remove the `manager` holder | **200**. The lists show no **New** button. | Under `single`, an org admin now assigns inside the boundary as well. That is objectstack-ai/objectstack#24136's grant, which is not specific to `isolated`, and the console follows the server's grant with no posture branch. The platform admin's path is the same as before. ## 2. Dead `sys_position` display entry removed (internal, no behaviour change) - `packages/app-shell/src/utils/approverIdentity.ts`: removed `sys_position` from `DISPLAY_FIELDS_BY_OBJECT`. No `data` binding names it, because the installed spec binds `position` to the registry (objectstack-ai/objectstack#22710). - The `approverValueIsName` docblock now describes the registry arm and the `data` arm as they are. - `packages/app-shell/src/hooks/useApproverDirectory.ts`: comment-only. Its docblock still listed a position row among the directory objects it reads. ## 3. The pre-publish capability lint read no registry capabilities (reproduced, fixed) `@objectstack/lint`'s `validateCapabilityReferences` resolves a reference against three sources: the built-in capabilities, `stack.capabilities` (the `defineCapability` declarations) and every permission set's `systemPermissions`. `lintDraftCapabilityReferences` built a pseudo-stack with no `capabilities`. So a capability declared with `defineCapability` and granted by no set was reported as resolving nowhere on every draft that required it. **Measured live** on the `single` rig with Publish from Home. Two object drafts were used. One requires `showcase.restricted_ops`: `GET /api/v1/meta/capability` serves it, and no permission set grants it. The other requires the misspelling `showcase.restricted_opz`. - Before: two toasts, one false (`restricted_ops`) and one real (the typo). - After: one toast, the typo only. **Fix** (`packages/app-shell/src/preview/capabilityLint.ts`): the pseudo-stack now carries `capabilities` read from `client.list('capability', {})`, unwrapped and stripped the same way as the permission rows. If that read fails, the list is empty, so the lint runs on the other sources and stays advisory. ## Tests - New `capabilityLint.registryCapabilities-7611.test.ts` runs the real `validateCapabilityReferences`, not a stand-in. It has four cases: the registry serves the capability; the row comes in an `{ item }` envelope; a CONTROL where the registry is empty and both drafts warn; and a failed capability read, which still gives two warnings and does not throw. - **Ablation:** with `capabilities,` removed from the stack, 2 of the 4 new tests fail (`Tests 2 failed | 2 passed (4)`). Restoring gives blob `3a9ac204` = HEAD. - `pnpm exec vitest run packages/app-shell/src/preview packages/app-shell/src/utils/approverIdentity packages/app-shell/src/hooks/useApproverDirectory`: 34 files, 252 tests passed. ## Gates `turbo run type-check --filter=@object-ui/app-shell` (it builds the workspace dependencies first) passes: 30 of 30 tasks, exit 0. `eslint` on the four touched source and test files: 0 errors. There are 7 `no-explicit-any` warnings, all on lines this diff does not change. These checks all pass: `check-changeset-presence`, `check-changeset-claims`, `check-pending-changeset-literals`, `check-changeset-no-major`, `check-changeset-fixed`, `check-control-bytes`, `check-test-path-roots`, `check-vi-mock-specifiers`, and `check:new-line-citations` (0 new citations). ## File surface Everything is inside the claimed surface except two files, both declared here: - `useApproverDirectory.ts`: comment-only, the same stale position wording as item 2. - `content/docs/guide/console.md`: one sentence naming the sources the capability lint resolves against (AGENTS.md: docs-driven). The branch is `claude/issue-7611-isolated-assign-reverify`. It is based on `main` `2c4e5f5`, and `main` has not moved since. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) --- _Generated by [Claude Code](https://claude.ai/code/session_01WvJkpohCWbizJavd86HTZz)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WvJkpohCWbizJavd86HTZz --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #24136
Clause-②: no
What this does
ADR-0090 D12, as amended on 2026-10-17 (ruling of record 6228728551 on #23960, letter B), names three kinds of administrator: tenant-level, organization-level and delegated. Until now
plugin-securityenforced only the first and the last. This PR enforces the organization-level kind, exactly as the ADR note defines it. There is no fourth condition and no new denylist entry beyond the three agent ceilings the ADR names.The boundary. An organization administrator holds
organization_adminororganization_admin_no_bypassfor its active organization and is not a platform administrator. It may create and removesys_user_positionandsys_user_permission_setrows of its own organization, for that organization's members. Every permission set the row distributes must be inside the organization boundary. For a direct grant, that is the granted set. For a position, it is each set the position's definition lists, plus the set of the position's own name, which the resolver also grants. A set is inside when:objects['*']entry carries neitherviewAllRecordsnormodifyAllRecords;adminScope;systemPermissionsis a capability the security catalog declaresscope: 'org'. A name no declaration carries is not an organization capability.Outside by name:
admin_full_access,organization_admin,organization_admin_no_bypass, and the three D10 agent ceilings (mcp_agent_data_read,mcp_agent_data_write,mcp_agent_restricted, the ADR's denylist). A set the catalog does not hold, and a position the catalog does not hold, cannot be shown to be inside, so they are outside too.The refusals. Each answers
403 PERMISSION_DENIED. The message names the set and the failed condition, or names the subject the refusal is about:sys_member) rows: administrator status comes from the membership grade alone.A row inserted with no organization is stamped with the caller's organization once admitted, so this right never mints an organization-less grant. A target who is not a member of the organization is refused
400 VALIDATION_FAILEDby the existing grant-holder membership hooks. Those hooks judge every writer and are not duplicated here. The ADR-0068 D2 identity positions (platform_admin,org_*) are likewise refused400by the existing object rule, for every writer.Changes (all in
@objectstack/plugin-security)delegated-admin-gate.ts). "Tenant-level" now means a superuser wildcard from any set other than the two organization-administrator sets. The walledorganization_adminwas read as tenant-level before, and the ADR note says it is organization-level. A caller holding an organization-administrator set is judged byorganizationAdminFailureafter the self-delegation path. An organization administrator that also holds anadminScopefalls back to its D12 delegated scopes when the organization boundary refuses. Platform administrators and D12 delegates take exactly the paths they took before, and everyone else is denied as before. The exportedisTenantAdminis unchanged.objects/default-permission-sets.ts).organization_admingetsallowCreate: trueandallowDelete: trueon both assignment tables, andallowEditstaysfalse.deriveWallLessOrgAdmincarries this to the no-bypass variant unchanged. The anti-escalation comment now states the boundary.describeDelegableScope, and through itGET /security/my-delegable-scope, reports an organization administrator withisTenantAdmin: false. Every unit of its organization is placeable. Only positions inside the boundary are assignable, which excludes the reserved identity names the write path refuses.explainAccessForCalleradmits an organization administrator explaining a member of its own organization (organizationAdminCoversUser).DelegableScopeshape.invitation-placement.tsandauto-org-admin-grant.tsnow state the boundary as the safety premise instead of the read-only overrides.minorchangeset for@objectstack/plugin-security(ruling item 3). There is nopackages/specchange, no new endpoint and no membership-model change.Tests
delegated-admin-gate.test.ts: a new describe for the organization administrator, run for both variants. It covers in-boundary sets and positions; every condition and every by-name exclusion; a mixed bundle; the position's own-name set; self-assignment; a foreign-organization row and foreign or organization-less removals; a foreign business unit; edit in place; filter delete; no organization;sys_member; the platform administrator unchanged; a member denied; the organization administrator that is also a delegate; and both doors. Every refusal assertscodeandstatus.objects/rbac-objects.test.ts: the old read-only pin is now the boundary pin, run over the sets this package actually ships. Neither variant can assignadmin_full_access, anadminScopeset, a platform-capability set or either organization-administrator set, to anyone, itself included.member_default,viewer_readonlyand an organization-capability set are the in-boundary controls.invitation-placement.test.ts: an organization administrator's invitation, judged by the real gate. In-boundary positions are issuable. A position carryingadmin_full_access, or a unit of another organization, is refused.org-admin-assignment-boundary.posture.test.ts(new): the three-posture acceptance on a real stack. It runs a realObjectQL, a real SQLiteSqlDriverand the realSecurityPlugin: gate, overrides, CRUD and membership hooks in production order.single,groupandisolated; platform administrator,organization_admin,organization_admin_no_bypassand member; insert and delete; both tables; an in-boundary and an out-of-boundary set. That is 3 × 4 × 2 × 2 = 48 cells, each asserting both insert and delete.403 PERMISSION_DENIEDnamingadmin_full_accessand the by-name condition. The platform administrator is admitted in every cell, and the member is refused in every cell.SharingServicePluginis not composed: the assignment tables carry no sharing, and the stack the gate judges is complete without it.get-effective-object-permissions.test.ts: the security: the effective permission map folds a super-user'*'over the same set's narrower explicit entries — a walled org admin getscan('sys_position','edit')true where enforcement refuses #20136 parity case followed the overrides. Edit stays refused, create and delete are now granted, andcan()agrees with the evaluator.docs/qa/platform-checklist/areas/access-security.json: one item,access-security.org-admin-assignment-boundary, recording this run. Its pins are the three files above.Ablations (each negative pin, forbidden behaviour put back, red, then restored byte-identical to HEAD)
delegated-admin-gate.tsorganizationBoundaryFailureadmits every setallowEdit: falsestill refuses: defence in depth)organization_adminread as tenant-level again (the oldisTenantAdminshort-circuit)Every leg ran through
scripts/ablation-replace.mjs: anchor 1 → 0, the blob moved, and the restore was verified asblob == HEAD,git diff HEADempty. The tests importsrcdirectly, so nodistwas involved.Verification at
20eb5f1f(the head this PR opens with)pnpm --filter '@objectstack/plugin-security^...' build:VERDICT command-exit 0.@objectstack/plugin-securityfull suite: 190 files passed, 3883 passed, 45 skipped.typecheck: exit 0.dispatch-gates --commands(no paths) derived 71 families. Run with exit codes captured before any pipe: 70 exit 0, 1 exit 3. The exit-3 one ischeck-plugin-teardown-shape.mjs --self-test, whose positive control is pinned to a commit this shallow clone cannot reach. It is NOT MEASURED and left to CI.--ranreconciliation: 71 derived, 70 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 unrun.pnpm check:where-matcherreddened the first gate run, because the new test double read$-combinators as field names. It now refuses them, and the gate passes.test:reporuns in CI.Rulings carried
Clause-②: no,minor, and the denylist fallback as the ADR names it (the three agent ceilings).extendcontributor that loosens a platform object's default (#24164 ruling E′) #24341 (6278784335): a delegated principal's own ceiling leg collects no object default. This PR does not touch that collection.Decisions made in this PR (each also in the dev report)
adminScope. If neither admits the write, the refusal is the delegated one.Acceptance notes
my-delegable-scopereports placeable units and assignable positions. Expect no console code; a measured gap there is adomain:uicard.packages/plugins/plugin-auth/src/auth-manager.ts(the comment abovebeforeCreateInvitation) still argues thatorganization_adminis kept read-only on the assignment tables. That premise is retired here, and the code under the comment is still right.describeDelegableScopefor a platform administrator) also offers the reserved identity positions, which the write path refuses. This PR excludes them only on the organization-level branch.docs/design/permission-model.md§10 still describes delegated administration only, as the spec seat noted on this card. Not edited here.setsBoundToPosition, noted on this card by the spec seat). Not reproduced; the delegated path is unchanged here per the dispatch.Generated by Claude Code